AWSTemplateFormatVersion: '2010-09-09'
Description: >
  AppStream / WorkSpaces Applications Scheduler Remediator.
  Purpose-specific, opt-in remediation module for stopping or starting AppStream 2.0
  fleets and image builders on demand or by EventBridge schedule. This module is
  separate from the cost optimization assessment/reporting template.

Parameters:
  ProjectName:
    Type: String
    Default: appstream-scheduler-remediator
    Description: Base name used for created resources.

  TargetRegionsCsv:
    Type: String
    Default: all-enabled
    Description: Comma-separated AWS Regions to scan, or all-enabled to scan all enabled Regions.

  ExcludedRegionsCsv:
    Type: String
    Default: ap-northeast-3
    Description: Comma-separated AWS Regions to exclude from scanning/remediation.

  DryRun:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: When true, only report what would be stopped/started. When false, perform eligible actions.

  ActionMode:
    Type: String
    Default: status-only
    AllowedValues:
      - status-only
      - stop
      - start
    Description: Action to perform. status-only never changes resources.

  TargetResourceTypes:
    Type: String
    Default: fleets,image-builders
    Description: Comma-separated resource types to target. Valid values are fleets and image-builders.

  IncludeNamePrefixesCsv:
    Type: String
    Default: ''
    Description: Optional comma-separated resource name prefixes to include. Leave blank to include all.

  ExcludeNamePrefixesCsv:
    Type: String
    Default: ''
    Description: Optional comma-separated resource name prefixes to exclude.

  RequireApprovalTag:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: >
      When true, only act on resources tagged with ApprovalTagKey=ApprovalTagValue.
      For broad scheduling, set false only after reviewing dry-run output and configuring exclusions.

  ApprovalTagKey:
    Type: String
    Default: CostOptApproved
    Description: Tag key required when RequireApprovalTag is true.

  ApprovalTagValue:
    Type: String
    Default: 'true'
    Description: Tag value required when RequireApprovalTag is true.

  IgnoreTagKeysCsv:
    Type: String
    Default: CostOptIgnore,DoNotStop,DoNotStart,DoNotModify
    Description: Comma-separated tag keys that prevent action when present, regardless of value.

  IgnoreTagPairsCsv:
    Type: String
    Default: CostOptIgnore=true,DoNotStop=true,DoNotStart=true,DoNotModify=true
    Description: Comma-separated key=value tag pairs that prevent action when present.

  MaxResourcesPerRun:
    Type: Number
    Default: 50
    Description: Safety limit. 0 means no limit.

  ContinueOnAccessDenied:
    Type: String
    Default: 'true'
    AllowedValues: ['true', 'false']
    Description: Continue scanning other Regions/resources when blocked by IAM/SCP.

  ScheduleEnabled:
    Type: String
    Default: 'false'
    AllowedValues: ['true', 'false']
    Description: Whether to create one enabled EventBridge schedule using ScheduleActionMode.

  ScheduleExpression:
    Type: String
    Default: cron(0 23 ? * MON-FRI *)
    Description: EventBridge schedule expression used when ScheduleEnabled is true.

  ScheduleActionMode:
    Type: String
    Default: stop
    AllowedValues: ['status-only', 'stop', 'start']
    Description: Action used by the optional EventBridge schedule.

Conditions:
  IsScheduleEnabled: !Equals [!Ref ScheduleEnabled, 'true']

Resources:
  RemediatorRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub '${ProjectName}-${AWS::AccountId}-${AWS::Region}-role'
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
      Policies:
        - PolicyName: AppStreamSchedulerPolicy
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Sid: DiscoverRegions
                Effect: Allow
                Action:
                  - ec2:DescribeRegions
                Resource: '*'
              - Sid: ReadAppStreamResources
                Effect: Allow
                Action:
                  - appstream:DescribeFleets
                  - appstream:DescribeImageBuilders
                  - appstream:ListTagsForResource
                Resource: '*'
              - Sid: StartStopAppStreamResources
                Effect: Allow
                Action:
                  - appstream:StartFleet
                  - appstream:StopFleet
                  - appstream:StartImageBuilder
                  - appstream:StopImageBuilder
                Resource: '*'

  RemediatorFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Ref ProjectName
      Description: Purpose-specific scheduler Lambda for AppStream fleets and image builders.
      Runtime: python3.12
      Handler: index.handler
      Timeout: 900
      MemorySize: 512
      Role: !GetAtt RemediatorRole.Arn
      Environment:
        Variables:
          TARGET_REGIONS_CSV: !Ref TargetRegionsCsv
          EXCLUDED_REGIONS_CSV: !Ref ExcludedRegionsCsv
          DRY_RUN: !Ref DryRun
          ACTION_MODE: !Ref ActionMode
          TARGET_RESOURCE_TYPES: !Ref TargetResourceTypes
          INCLUDE_NAME_PREFIXES_CSV: !Ref IncludeNamePrefixesCsv
          EXCLUDE_NAME_PREFIXES_CSV: !Ref ExcludeNamePrefixesCsv
          REQUIRE_APPROVAL_TAG: !Ref RequireApprovalTag
          APPROVAL_TAG_KEY: !Ref ApprovalTagKey
          APPROVAL_TAG_VALUE: !Ref ApprovalTagValue
          IGNORE_TAG_KEYS_CSV: !Ref IgnoreTagKeysCsv
          IGNORE_TAG_PAIRS_CSV: !Ref IgnoreTagPairsCsv
          MAX_RESOURCES_PER_RUN: !Ref MaxResourcesPerRun
          CONTINUE_ON_ACCESS_DENIED: !Ref ContinueOnAccessDenied
      Code:
        ZipFile: |
          import json
          import os
          import traceback
          from datetime import datetime, timezone

          import boto3
          from botocore.exceptions import ClientError

          def utc_now_iso():
              return datetime.now(timezone.utc).isoformat()

          def env_bool(name, default=False):
              value = os.environ.get(name, str(default)).strip().lower()
              return value in ('1', 'true', 'yes', 'y')

          def env_int(name, default=0):
              try:
                  return int(os.environ.get(name, str(default)).strip())
              except Exception:
                  return default

          def split_csv(value):
              if value is None:
                  return []
              return [item.strip() for item in str(value).split(',') if item.strip()]

          def parse_tag_pairs(value):
              pairs = {}
              for item in split_csv(value):
                  if '=' not in item:
                      continue
                  key, val = item.split('=', 1)
                  key = key.strip()
                  val = val.strip()
                  if key:
                      pairs[key] = val
              return pairs

          def get_enabled_regions(excluded_regions):
              requested = os.environ.get('TARGET_REGIONS_CSV', 'all-enabled').strip()
              if requested and requested.lower() != 'all-enabled':
                  regions = split_csv(requested)
              else:
                  ec2 = boto3.client('ec2', region_name=os.environ.get('AWS_REGION', 'us-east-1'))
                  response = ec2.describe_regions(AllRegions=False)
                  regions = sorted([r['RegionName'] for r in response.get('Regions', [])])
              return [r for r in regions if r not in excluded_regions]

          def name_included(name, include_prefixes):
              if not include_prefixes:
                  return True
              return any(name.startswith(prefix) for prefix in include_prefixes)

          def name_excluded(name, exclude_prefixes):
              if not exclude_prefixes:
                  return False
              return any(name.startswith(prefix) for prefix in exclude_prefixes)

          def has_ignore_tag(tag_map, ignore_tag_keys, ignore_tag_pairs):
              for key in ignore_tag_keys:
                  if key in tag_map:
                      return True, 'ignore_tag_key=' + key
              for key, value in ignore_tag_pairs.items():
                  if tag_map.get(key) == value:
                      return True, 'ignore_tag_pair=' + key + '=' + value
              return False, ''

          def has_approval_tag(tag_map, settings):
              if not settings['require_approval_tag']:
                  return True
              return tag_map.get(settings['approval_tag_key']) == settings['approval_tag_value']

          def list_tags(appstream, arn):
              if not arn:
                  return {}
              try:
                  response = appstream.list_tags_for_resource(ResourceArn=arn)
                  return response.get('Tags', {}) or {}
              except ClientError:
                  return {}

          def normalize_fleet(fleet, region, appstream):
              name = fleet.get('Name')
              arn = fleet.get('Arn')
              tags = list_tags(appstream, arn)
              return {
                  'resource_type': 'fleet',
                  'name': name,
                  'arn': arn,
                  'region': region,
                  'state': fleet.get('State'),
                  'instance_type': fleet.get('InstanceType'),
                  'fleet_type': fleet.get('FleetType'),
                  'compute_capacity_status': fleet.get('ComputeCapacityStatus'),
                  'tags': tags
              }

          def normalize_image_builder(builder, region, appstream):
              name = builder.get('Name')
              arn = builder.get('Arn')
              tags = list_tags(appstream, arn)
              return {
                  'resource_type': 'image-builder',
                  'name': name,
                  'arn': arn,
                  'region': region,
                  'state': builder.get('State'),
                  'instance_type': builder.get('InstanceType'),
                  'tags': tags
              }

          def action_needed(resource, action_mode):
              state = str(resource.get('state') or '').upper()
              if action_mode == 'status-only':
                  return False, 'status_only'
              if action_mode == 'stop':
                  if state in ('RUNNING', 'STARTING'):
                      return True, 'stop'
                  return False, 'already_not_running'
              if action_mode == 'start':
                  if state in ('STOPPED', 'STOPPING'):
                      return True, 'start'
                  return False, 'already_not_stopped'
              return False, 'unknown_action_mode'

          def describe_fleets(appstream):
              resources = []
              next_token = None
              while True:
                  kwargs = {}
                  if next_token:
                      kwargs['NextToken'] = next_token
                  response = appstream.describe_fleets(**kwargs)
                  resources.extend(response.get('Fleets', []))
                  next_token = response.get('NextToken')
                  if not next_token:
                      break
              return resources

          def describe_image_builders(appstream):
              resources = []
              next_token = None
              while True:
                  kwargs = {}
                  if next_token:
                      kwargs['NextToken'] = next_token
                  response = appstream.describe_image_builders(**kwargs)
                  resources.extend(response.get('ImageBuilders', []))
                  next_token = response.get('NextToken')
                  if not next_token:
                      break
              return resources

          def apply_action(appstream, resource, action_mode):
              rtype = resource['resource_type']
              name = resource['name']
              if action_mode == 'stop' and rtype == 'fleet':
                  appstream.stop_fleet(Name=name)
              elif action_mode == 'start' and rtype == 'fleet':
                  appstream.start_fleet(Name=name)
              elif action_mode == 'stop' and rtype == 'image-builder':
                  appstream.stop_image_builder(Name=name)
              elif action_mode == 'start' and rtype == 'image-builder':
                  appstream.start_image_builder(Name=name)
              else:
                  raise ValueError('Unsupported action/resource combination: ' + action_mode + '/' + rtype)

          def scan_region(region, settings):
              appstream = boto3.client('appstream', region_name=region)
              result = {
                  'region': region,
                  'evaluated': 0,
                  'fleets': 0,
                  'image_builders': 0,
                  'eligible': 0,
                  'would_act': 0,
                  'acted': 0,
                  'skipped_include_prefix': 0,
                  'skipped_exclude_prefix': 0,
                  'skipped_missing_approval_tag': 0,
                  'skipped_ignore_tag': 0,
                  'skipped_no_action_needed': 0,
                  'access_denied': 0,
                  'failed': 0,
                  'failures': [],
                  'candidates': [],
                  'acted_resources': []
              }

              raw_resources = []
              try:
                  if 'fleets' in settings['target_resource_types']:
                      for fleet in describe_fleets(appstream):
                          raw_resources.append(normalize_fleet(fleet, region, appstream))
                  if 'image-builders' in settings['target_resource_types']:
                      for builder in describe_image_builders(appstream):
                          raw_resources.append(normalize_image_builder(builder, region, appstream))
              except ClientError as e:
                  code = e.response.get('Error', {}).get('Code', 'Unknown')
                  message = e.response.get('Error', {}).get('Message', str(e))
                  result['failures'].append({'operation': 'DescribeAppStreamResources', 'error_code': code, 'error_message': message})
                  if code in ('AccessDeniedException', 'AccessDenied', 'UnauthorizedOperation') or 'explicit deny' in message.lower():
                      result['access_denied'] += 1
                  else:
                      result['failed'] += 1
                  if not settings['continue_on_access_denied']:
                      raise
                  return result

              for resource in raw_resources:
                  if settings['max_resources_per_run'] and (result['would_act'] + result['acted']) >= settings['max_resources_per_run']:
                      result['stopped_due_to_max_resources_per_run'] = True
                      return result

                  result['evaluated'] += 1
                  if resource['resource_type'] == 'fleet':
                      result['fleets'] += 1
                  if resource['resource_type'] == 'image-builder':
                      result['image_builders'] += 1

                  name = resource.get('name') or ''
                  tags = resource.get('tags') or {}

                  if not name_included(name, settings['include_name_prefixes']):
                      result['skipped_include_prefix'] += 1
                      continue

                  if name_excluded(name, settings['exclude_name_prefixes']):
                      result['skipped_exclude_prefix'] += 1
                      continue

                  ignore, ignore_reason = has_ignore_tag(tags, settings['ignore_tag_keys'], settings['ignore_tag_pairs'])
                  if ignore:
                      result['skipped_ignore_tag'] += 1
                      item = dict(resource)
                      item['action'] = 'skipped_ignore_tag'
                      item['reason'] = ignore_reason
                      result['candidates'].append(item)
                      continue

                  if not has_approval_tag(tags, settings):
                      result['skipped_missing_approval_tag'] += 1
                      item = dict(resource)
                      item['action'] = 'skipped_missing_approval_tag'
                      item['required_tag'] = settings['approval_tag_key'] + '=' + settings['approval_tag_value']
                      result['candidates'].append(item)
                      continue

                  needed, action_reason = action_needed(resource, settings['action_mode'])
                  if not needed:
                      result['skipped_no_action_needed'] += 1
                      item = dict(resource)
                      item['action'] = 'skipped_no_action_needed'
                      item['reason'] = action_reason
                      result['candidates'].append(item)
                      continue

                  result['eligible'] += 1
                  item = dict(resource)
                  item['action'] = 'would_' + settings['action_mode'] if settings['dry_run'] else settings['action_mode']

                  if settings['dry_run']:
                      result['would_act'] += 1
                      result['candidates'].append(item)
                      continue

                  try:
                      apply_action(appstream, resource, settings['action_mode'])
                      result['acted'] += 1
                      result['acted_resources'].append(item)
                  except ClientError as e:
                      code = e.response.get('Error', {}).get('Code', 'Unknown')
                      message = e.response.get('Error', {}).get('Message', str(e))
                      result['failures'].append({'resource_type': resource['resource_type'], 'name': name, 'operation': settings['action_mode'], 'error_code': code, 'error_message': message})
                      if code in ('AccessDeniedException', 'AccessDenied', 'UnauthorizedOperation') or 'explicit deny' in message.lower():
                          result['access_denied'] += 1
                      else:
                          result['failed'] += 1
                      if not settings['continue_on_access_denied']:
                          raise

              return result

          def handler(event, context):
              settings = {
                  'excluded_regions': split_csv(os.environ.get('EXCLUDED_REGIONS_CSV', '')),
                  'dry_run': env_bool('DRY_RUN', True),
                  'action_mode': os.environ.get('ACTION_MODE', 'status-only'),
                  'target_resource_types': split_csv(os.environ.get('TARGET_RESOURCE_TYPES', 'fleets,image-builders')),
                  'include_name_prefixes': split_csv(os.environ.get('INCLUDE_NAME_PREFIXES_CSV', '')),
                  'exclude_name_prefixes': split_csv(os.environ.get('EXCLUDE_NAME_PREFIXES_CSV', '')),
                  'require_approval_tag': env_bool('REQUIRE_APPROVAL_TAG', True),
                  'approval_tag_key': os.environ.get('APPROVAL_TAG_KEY', 'CostOptApproved'),
                  'approval_tag_value': os.environ.get('APPROVAL_TAG_VALUE', 'true'),
                  'ignore_tag_keys': split_csv(os.environ.get('IGNORE_TAG_KEYS_CSV', '')),
                  'ignore_tag_pairs': parse_tag_pairs(os.environ.get('IGNORE_TAG_PAIRS_CSV', '')),
                  'max_resources_per_run': env_int('MAX_RESOURCES_PER_RUN', 50),
                  'continue_on_access_denied': env_bool('CONTINUE_ON_ACCESS_DENIED', True),
              }

              if isinstance(event, dict):
                  if 'dry_run' in event:
                      settings['dry_run'] = bool(event['dry_run'])
                  if 'action_mode' in event:
                      settings['action_mode'] = str(event['action_mode'])
                  if 'require_approval_tag' in event:
                      settings['require_approval_tag'] = bool(event['require_approval_tag'])
                  if 'target_resource_types' in event:
                      settings['target_resource_types'] = list(event['target_resource_types'])
                  if 'include_name_prefixes' in event:
                      settings['include_name_prefixes'] = list(event['include_name_prefixes'])
                  if 'exclude_name_prefixes' in event:
                      settings['exclude_name_prefixes'] = list(event['exclude_name_prefixes'])
                  if 'max_resources_per_run' in event:
                      settings['max_resources_per_run'] = int(event['max_resources_per_run'])
                  if 'target_regions' in event and event['target_regions']:
                      os.environ['TARGET_REGIONS_CSV'] = ','.join(event['target_regions'])
                  if 'excluded_regions' in event and event['excluded_regions']:
                      settings['excluded_regions'] = list(event['excluded_regions'])

              if settings['action_mode'] not in ('status-only', 'stop', 'start'):
                  settings['action_mode'] = 'status-only'

              settings['target_resource_types'] = [x for x in settings['target_resource_types'] if x in ('fleets', 'image-builders')]
              if not settings['target_resource_types']:
                  settings['target_resource_types'] = ['fleets', 'image-builders']

              summary = {
                  'started_at': utc_now_iso(),
                  'dry_run': settings['dry_run'],
                  'action_mode': settings['action_mode'],
                  'target_resource_types': settings['target_resource_types'],
                  'require_approval_tag': settings['require_approval_tag'],
                  'approval_tag': settings['approval_tag_key'] + '=' + settings['approval_tag_value'],
                  'include_name_prefixes': settings['include_name_prefixes'],
                  'exclude_name_prefixes': settings['exclude_name_prefixes'],
                  'ignore_tag_keys': settings['ignore_tag_keys'],
                  'ignore_tag_pairs': settings['ignore_tag_pairs'],
                  'max_resources_per_run': settings['max_resources_per_run'],
                  'excluded_regions': settings['excluded_regions'],
                  'regions': [],
                  'totals': {
                      'evaluated': 0,
                      'fleets': 0,
                      'image_builders': 0,
                      'eligible': 0,
                      'would_act': 0,
                      'acted': 0,
                      'skipped_include_prefix': 0,
                      'skipped_exclude_prefix': 0,
                      'skipped_missing_approval_tag': 0,
                      'skipped_ignore_tag': 0,
                      'skipped_no_action_needed': 0,
                      'access_denied': 0,
                      'failed': 0
                  },
                  'region_results': []
              }

              try:
                  regions = get_enabled_regions(settings['excluded_regions'])
                  summary['regions'] = regions
                  for region in regions:
                      try:
                          region_result = scan_region(region, settings)
                      except Exception as e:
                          region_result = {
                              'region': region,
                              'evaluated': 0,
                              'fleets': 0,
                              'image_builders': 0,
                              'eligible': 0,
                              'would_act': 0,
                              'acted': 0,
                              'skipped_include_prefix': 0,
                              'skipped_exclude_prefix': 0,
                              'skipped_missing_approval_tag': 0,
                              'skipped_ignore_tag': 0,
                              'skipped_no_action_needed': 0,
                              'access_denied': 0,
                              'failed': 1,
                              'failures': [{'error_code': type(e).__name__, 'error_message': str(e), 'trace': traceback.format_exc()}],
                              'candidates': [],
                              'acted_resources': []
                          }
                      summary['region_results'].append(region_result)
                      for key in summary['totals']:
                          summary['totals'][key] += int(region_result.get(key, 0) or 0)
              finally:
                  summary['ended_at'] = utc_now_iso()
                  print(json.dumps(summary, indent=2, default=str))
              return summary

  RemediatorScheduleRule:
    Type: AWS::Events::Rule
    Condition: IsScheduleEnabled
    Properties:
      Name: !Sub '${ProjectName}-schedule'
      Description: Scheduled run for AppStream Scheduler Remediator.
      ScheduleExpression: !Ref ScheduleExpression
      State: ENABLED
      Targets:
        - Arn: !GetAtt RemediatorFunction.Arn
          Id: AppStreamSchedulerRemediatorTarget
          Input: !Sub '{"source":"eventbridge-schedule","dry_run":false,"action_mode":"${ScheduleActionMode}"}'

  RemediatorSchedulePermission:
    Type: AWS::Lambda::Permission
    Condition: IsScheduleEnabled
    Properties:
      FunctionName: !Ref RemediatorFunction
      Action: lambda:InvokeFunction
      Principal: events.amazonaws.com
      SourceArn: !GetAtt RemediatorScheduleRule.Arn

Outputs:
  RemediatorFunctionName:
    Description: Lambda function name.
    Value: !Ref RemediatorFunction

  RemediatorFunctionArn:
    Description: Lambda function ARN.
    Value: !GetAtt RemediatorFunction.Arn

  DryRunSetting:
    Description: Current DryRun setting.
    Value: !Ref DryRun

  ActionModeSetting:
    Description: Current ActionMode setting.
    Value: !Ref ActionMode

  ManualStatusExample:
    Description: Example manual status-only invocation payload.
    Value: !Sub 'aws lambda invoke --function-name ${RemediatorFunction} --cli-binary-format raw-in-base64-out --payload ''{"dry_run":true,"action_mode":"status-only","require_approval_tag":false}'' response.json'

  ManualStopDryRunExample:
    Description: Example manual stop dry-run invocation payload.
    Value: !Sub 'aws lambda invoke --function-name ${RemediatorFunction} --cli-binary-format raw-in-base64-out --payload ''{"dry_run":true,"action_mode":"stop","require_approval_tag":false}'' response.json'
